# Copyright (c) 2015 Presto Labs Pte. Ltd.
# Author: jaewon

import os
import logging
import pyinotify
import re
import sys
from tornado.ioloop import IOLoop
from tornado.ioloop import PeriodicCallback


class _Status:
  READY = 0
  WATCHING = 1
  WATCHING_SOON = 2


class DirectoryWatcherHandler(object):
  def handle_log_added(self, path):
    raise NotImplementedError()

  def handle_log_removed(self, path):
    raise NotImplementedError()

  def handle_log_modified(self, path):
    raise NotImplementedError()

  def handle_unreachable(self, target_dir):
    raise NotImplementedError()


class _EventHandler(pyinotify.ProcessEvent):
  def __init__(self, controller, handler, logger=None):
    self._controller = controller
    self._handler = handler
    self._ioloop = controller._ioloop
    assert isinstance(controller, DirectoryWatcher)
    assert isinstance(handler, DirectoryWatcherHandler)
    self._logs_watching = {}
    self._logger = logger or logging.getLogger(__name__)

  def retire(self):
    self._controller = None
    self._watching_logs = None

  def _invoker(self, f, v):
    if self._controller:
      f(v)

  def _invoke_handler(self, f, v):
    self._ioloop.add_callback(self._invoker, f, v)

  def _is_watchable(self, log_file):
    if log_file in self._logs_watching:
      watchable = self._logs_watching[log_file]
    else:
      watchable = self._controller._is_watchable(log_file)
      self._logs_watching[log_file] = watchable
    return watchable

  def add(self, log_file):
    if self._is_watchable(log_file):
      self._invoke_handler(self._handler.handle_log_added, log_file)

  def remove(self, log_file):
    if self._is_watchable(log_file):
      self._logs_watching[log_file] = False
      self._invoke_handler(self._handler.handle_log_removed, log_file)

  def remove_all(self):
    for f, watching in self._logs_watching.items():
      if watching:
        self._invoke_handler(self._handler.handle_log_removed, f)

  def process_default(self, event):
    if not self._controller:
      return
    if event.dir:
      self.process_dir(event)
    else:
      self.process_file(event)

  def process_dir(self, event):
    if (event.mask & pyinotify.IN_DELETE_SELF or event.mask & pyinotify.IN_MOVE_SELF
        or event.mask & pyinotify.IN_UNMOUNT or event.mask & pyinotify.IN_Q_OVERFLOW):
      self.remove_all()
      self._invoke_handler(self._handler.handle_unreachable, self._controller._target_dir)
      self._controller._stop_watching()
      self._logger.warning('%s removed.' % self._controller._target_dir)

  def process_file(self, event):
    # Log file is added.
    if (event.mask & pyinotify.IN_CREATE or event.mask & pyinotify.IN_MOVED_TO):
      self.add(event.pathname)

    # Log file is removed.
    elif (event.mask & pyinotify.IN_DELETE or event.mask & pyinotify.IN_MOVED_FROM):
      self.remove(event.pathname)

    elif event.mask & pyinotify.IN_MODIFY:
      if self._is_watchable(event.pathname):
        self._invoke_handler(self._handler.handle_log_modified, event.pathname)


class DirectoryWatcher(object):
  def _convert_path(self, path):
    if self._use_relative_path:
      return os.path.relpath(path)
    else:
      return os.path.abspath(path)

  def __init__(self,
               target_dir,
               handler,
               retry_period_in_sec=3,
               include_regex=None,
               include_exact=None,
               exclude_regex=None,
               use_relative_path=True,
               recursive=False,
               verbose=False,
               logger=None):
    include_regex = include_regex or ['.']
    include_exact = include_exact or []
    exclude_regex = exclude_regex or ['^[.]', '^[~]', '[~]$']
    assert type(exclude_regex) == list
    self._handler = handler
    self._use_relative_path = use_relative_path
    self._target_dir = self._convert_path(target_dir)
    self._status = _Status.READY
    self._retry_period_in_sec = retry_period_in_sec
    self._verbose = verbose
    self._recursive = recursive

    self._include_regex = []
    for regex_str in include_regex:
      r = re.compile(regex_str)
      self._include_regex.append(r)

    self._include_exact = include_exact

    self._exclude_regex = []
    for regex_str in exclude_regex:
      r = re.compile(regex_str)
      self._exclude_regex.append(r)

    self._logger = logger or logging.getLogger(__name__)

  @property
  def target_dir(self):
    return self._target_dir

  def __del__(self):
    self.stop()

  def _is_watchable(self, filepath):
    if not os.path.isfile(filepath) or os.path.islink(filepath):
      return False
    filename = os.path.basename(filepath)
    for ex_filter in self._exclude_regex:
      if ex_filter.search(filename):
        return False
    for in_filter in self._include_regex:
      if in_filter.search(filename):
        return True
    for in_filter in self._include_exact:
      if in_filter == filename:
        return True
    return False

  def _periodic_check(self):
    assert self._status != _Status.READY
    if self._status == _Status.WATCHING:
      pass
    elif self._status == _Status.WATCHING_SOON:
      if self._verbose:
        self._logger.info('Try watching %s' % self._target_dir)
      self._start_watching()
    else:
      assert False

  def start(self, ioloop):
    assert self._status == _Status.READY
    self._status = _Status.WATCHING_SOON
    self._ioloop = ioloop
    self._event_handler = _EventHandler(self, self._handler)
    self._periodic_check_controller = PeriodicCallback(self._periodic_check,
                                                       self._retry_period_in_sec * 1000)
    self._periodic_check_controller.start()
    self._start_watching()

  def _start_watching(self):
    assert self._status == _Status.WATCHING_SOON
    if not os.path.exists(self._target_dir) or \
       not os.path.isdir(self._target_dir):
      if self._verbose:
        self._logger.warning('%s not exists.' % self._target_dir)
      return

    self._watch_manager = pyinotify.WatchManager()
    self._notifier = pyinotify.TornadoAsyncNotifier(watch_manager=self._watch_manager,
                                                    ioloop=self._ioloop,
                                                    default_proc_fun=self._event_handler)
    self._watch_manager.add_watch(
        self._target_dir,
        pyinotify.ALL_EVENTS,
        rec=self._recursive,  # Watch subdirectories.
        auto_add=self._recursive)

    self._status = _Status.WATCHING
    if self._verbose:
      self._logger.info('Start watching %s' % self._target_dir)

  def _stop_watching(self):
    assert self._status == _Status.WATCHING
    self._watch_manager.close()
    try:
      self._notifier.stop()
    except OSError:
      pass
    self._watch_manager = None
    self._notifier = None
    self._status = _Status.WATCHING_SOON

  def stop(self):
    if self._status == _Status.READY:
      return
    if self._status == _Status.WATCHING:
      self._stop_watching()
    self._event_handler.retire()
    self._event_handler = None
    self._periodic_check_controller.stop()
    self._periodic_check_controller = None
    self._ioloop = None
    self._status = _Status.READY


class _SimpleHandler(DirectoryWatcherHandler):
  def __init__(self):
    self._logger = logging.getLogger(__name__)

  def handle_log_added(self, path):
    self._logger.info('%s: added.', path)

  def handle_log_removed(self, path):
    self._logger.info('%s: removed.', path)

  def handle_log_modified(self, path):
    self._logger.info('%s: modified.', path)

  def handle_unreachable(self, target_dir):
    self._logger.info('%s: unreachable.', target_dir)


def main(argv=sys.argv):
  path = argv[1]
  ioloop = IOLoop()
  handler = _SimpleHandler()
  log_watcher = DirectoryWatcher(path, handler, verbose=True)
  try:
    log_watcher.start(ioloop)
    ioloop.start()
  except (KeyboardInterrupt, SystemExit):
    return 0
  except Exception:
    raise


if __name__ == "__main__":
  logging.basicConfig(
      level=logging.DEBUG,
      format='%(levelname)8s %(asctime)s %(name)s %(filename)s:%(lineno)d] %(message)s')

  sys.exit(main())
